@faststore/core 4.4.1-dev.0 → 4.5.0-dev.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.
Files changed (89) hide show
  1. package/.turbo/turbo-generate.log +4 -4
  2. package/.turbo/turbo-test.log +137 -51
  3. package/@generated/cached-operations.json +4 -0
  4. package/@generated/gql.ts +30 -0
  5. package/@generated/graphql.ts +141 -0
  6. package/@generated/persisted-documents.json +5 -0
  7. package/@generated/schema.graphql +100 -0
  8. package/CHANGELOG.md +26 -0
  9. package/cms/faststore/components/cms_component__recommendationshelf.jsonc +86 -0
  10. package/cms/faststore/schema.json +89 -0
  11. package/discovery.config.default.js +3 -0
  12. package/discovery.config.js +10 -1
  13. package/discovery.config.local.example.js +62 -0
  14. package/package.json +6 -6
  15. package/public/icons.svg +4 -0
  16. package/src/Layout.tsx +6 -0
  17. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +376 -0
  18. package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/index.ts +2 -0
  19. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +52 -23
  20. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +16 -6
  21. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
  22. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
  23. package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
  24. package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
  25. package/src/components/account/Layout/Layout.tsx +14 -5
  26. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotes.tsx +170 -0
  27. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable.tsx +194 -0
  28. package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountQuoteStatusBadge.tsx +15 -0
  29. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider.tsx +162 -0
  30. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/section.module.scss +49 -0
  31. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesSelectedTags/MyAccountQuotesSelectedTags.tsx +125 -0
  32. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/MyAccountQuotesStatusSelector.tsx +107 -0
  33. package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/styles.module.scss +144 -0
  34. package/src/components/account/quotes/MyAccountListQuotes/index.ts +2 -0
  35. package/src/components/account/quotes/MyAccountListQuotes/quoteFilters.ts +89 -0
  36. package/src/components/account/quotes/MyAccountListQuotes/styles.module.scss +174 -0
  37. package/src/components/cms/global/Components.ts +10 -0
  38. package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
  39. package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
  40. package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
  41. package/src/components/sections/RecommendationShelf/index.ts +1 -0
  42. package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
  43. package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
  44. package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
  45. package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
  46. package/src/customizations/src/myAccount/extensions/quotes/after.tsx +5 -0
  47. package/src/customizations/src/myAccount/extensions/quotes/before.tsx +5 -0
  48. package/src/pages/[slug]/p.tsx +45 -0
  49. package/src/pages/pvt/account/quotes/index.tsx +264 -0
  50. package/src/sdk/account/changeContractToken.ts +119 -0
  51. package/src/sdk/account/clearPersistedSessionState.ts +26 -0
  52. package/src/sdk/account/getMyAccountRoutes.ts +7 -0
  53. package/src/sdk/account/refreshToken.ts +21 -8
  54. package/src/sdk/account/useAvailableContracts.ts +49 -0
  55. package/src/sdk/account/useSwitchContract.ts +60 -0
  56. package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
  57. package/src/sdk/analytics/utils.ts +9 -0
  58. package/src/utils/quoteStatus.ts +27 -0
  59. package/src/utils/retry.ts +69 -0
  60. package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
  61. package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
  62. package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
  63. package/test/components/RecommendationShelf/vrn.test.ts +56 -0
  64. package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
  65. package/test/components/account/Layout.test.tsx +62 -0
  66. package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
  67. package/test/components/account/OrganizationDrawerBody.test.tsx +52 -0
  68. package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
  69. package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
  70. package/test/components/account/doLogout.browser.test.ts +102 -0
  71. package/test/components/quotes/MyAccountListQuotes.test.tsx +232 -0
  72. package/test/components/quotes/MyAccountListQuotesTable.test.tsx +240 -0
  73. package/test/components/quotes/MyAccountQuoteStatusBadge.test.tsx +28 -0
  74. package/test/components/quotes/MyAccountQuotesFilterSlider.test.tsx +152 -0
  75. package/test/components/quotes/MyAccountQuotesSelectedTags.test.tsx +122 -0
  76. package/test/components/quotes/MyAccountQuotesStatusSelector.test.tsx +85 -0
  77. package/test/components/quotes/quoteFilters.test.ts +185 -0
  78. package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
  79. package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
  80. package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
  81. package/test/sdk/account/getMyAccountRoutes.test.ts +90 -0
  82. package/test/sdk/account/refreshToken.browser.test.ts +34 -0
  83. package/test/sdk/account/refreshToken.test.ts +87 -0
  84. package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
  85. package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
  86. package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
  87. package/test/sdk/analytics/utils.test.ts +23 -0
  88. package/test/server/index.test.ts +5 -0
  89. package/test/utils/retry.test.ts +59 -0
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ const mockDel = vi.hoisted(() => vi.fn())
8
+
9
+ vi.mock('idb-keyval', () => ({
10
+ del: mockDel,
11
+ }))
12
+
13
+ import { SESSION_READY_KEY } from '../../../src/sdk/session/storageKeys'
14
+ import {
15
+ STORAGE_KEY_CACHE_BUST_LAST_VALUE,
16
+ STORAGE_KEY_PERSON_ID,
17
+ } from '../../../src/utils/cookieCacheBusting'
18
+ import { clearPersistedSessionState } from '../../../src/sdk/account/clearPersistedSessionState'
19
+
20
+ describe('clearPersistedSessionState', () => {
21
+ beforeEach(() => {
22
+ mockDel.mockResolvedValue(undefined)
23
+ sessionStorage.setItem(SESSION_READY_KEY, '1')
24
+ sessionStorage.setItem(STORAGE_KEY_PERSON_ID, 'person-1')
25
+ sessionStorage.setItem(STORAGE_KEY_CACHE_BUST_LAST_VALUE, 'abc')
26
+ })
27
+
28
+ afterEach(() => {
29
+ vi.clearAllMocks()
30
+ sessionStorage.clear()
31
+ })
32
+
33
+ it('clears IndexedDB session and sessionStorage keys', async () => {
34
+ await clearPersistedSessionState()
35
+
36
+ expect(mockDel).toHaveBeenCalledWith('fs::session')
37
+ expect(sessionStorage.getItem(SESSION_READY_KEY)).toBeNull()
38
+ expect(sessionStorage.getItem(STORAGE_KEY_PERSON_ID)).toBeNull()
39
+ expect(sessionStorage.getItem(STORAGE_KEY_CACHE_BUST_LAST_VALUE)).toBeNull()
40
+ })
41
+ })
@@ -0,0 +1,20 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const mockDel = vi.hoisted(() => vi.fn())
4
+
5
+ vi.mock('idb-keyval', () => ({ del: mockDel }))
6
+
7
+ import { clearPersistedSessionState } from '../../../src/sdk/account/clearPersistedSessionState'
8
+
9
+ describe('clearPersistedSessionState (node)', () => {
10
+ afterEach(() => {
11
+ vi.clearAllMocks()
12
+ })
13
+
14
+ it('clears IndexedDB session when sessionStorage is unavailable', async () => {
15
+ mockDel.mockResolvedValue(undefined)
16
+
17
+ await expect(clearPersistedSessionState()).resolves.toBeUndefined()
18
+ expect(mockDel).toHaveBeenCalledWith('fs::session')
19
+ })
20
+ })
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ ORDERS_ROUTE,
5
+ PROFILE_ROUTE,
6
+ QUOTES_ROUTE,
7
+ ROUTES_ONLY_FOR_B2B_MEMBERS,
8
+ SECURITY_ROUTE,
9
+ USER_DETAILS_ROUTE,
10
+ getExtraMyAccountRoutes,
11
+ getMyAccountRoutes,
12
+ } from '../../../src/sdk/account/getMyAccountRoutes'
13
+
14
+ describe('getMyAccountRoutes', () => {
15
+ it('includes the default routes plus any extra routes provided', () => {
16
+ const result = getMyAccountRoutes({
17
+ routes: [{ title: 'Custom', route: '/custom' }],
18
+ })
19
+
20
+ expect(result.map((r) => r.route)).toEqual([
21
+ PROFILE_ROUTE,
22
+ ORDERS_ROUTE,
23
+ QUOTES_ROUTE,
24
+ USER_DETAILS_ROUTE,
25
+ SECURITY_ROUTE,
26
+ '/pvt/custom',
27
+ ])
28
+ })
29
+
30
+ it('prefixes routes with /pvt when missing', () => {
31
+ const result = getMyAccountRoutes({
32
+ routes: [{ title: 'Custom', route: '/custom' }],
33
+ })
34
+
35
+ expect(result.find((r) => r.title === 'Custom')?.route).toBe('/pvt/custom')
36
+ })
37
+
38
+ it('leaves titles unchanged when no labels are provided', () => {
39
+ const result = getMyAccountRoutes({ routes: [] })
40
+
41
+ expect(result.find((r) => r.route === QUOTES_ROUTE)?.title).toBe('Quotes')
42
+ })
43
+
44
+ it('overrides default route titles with matching CMS labels', () => {
45
+ const result = getMyAccountRoutes({
46
+ routes: [],
47
+ labels: { profileLabel: 'Meu Perfil', ordersLabel: 'Meus Pedidos' },
48
+ })
49
+
50
+ expect(result.find((r) => r.route === PROFILE_ROUTE)?.title).toBe(
51
+ 'Meu Perfil'
52
+ )
53
+ expect(result.find((r) => r.route === ORDERS_ROUTE)?.title).toBe(
54
+ 'Meus Pedidos'
55
+ )
56
+ })
57
+
58
+ it('falls back to the default title when a label key has no CMS value', () => {
59
+ const result = getMyAccountRoutes({
60
+ routes: [],
61
+ labels: { profileLabel: undefined },
62
+ })
63
+
64
+ expect(result.find((r) => r.route === PROFILE_ROUTE)?.title).toBe('Profile')
65
+ })
66
+
67
+ it('does not relabel routes with no matching label key (e.g. Quotes)', () => {
68
+ const result = getMyAccountRoutes({
69
+ routes: [],
70
+ labels: { profileLabel: 'Meu Perfil' },
71
+ })
72
+
73
+ expect(result.find((r) => r.route === QUOTES_ROUTE)?.title).toBe('Quotes')
74
+ })
75
+
76
+ it('marks Quotes as a B2B-only route', () => {
77
+ expect(ROUTES_ONLY_FOR_B2B_MEMBERS).toContain(QUOTES_ROUTE)
78
+ })
79
+ })
80
+
81
+ describe('getExtraMyAccountRoutes', () => {
82
+ it('filters out routes that are already part of the default set', () => {
83
+ const result = getExtraMyAccountRoutes([
84
+ { title: 'Profile', route: PROFILE_ROUTE },
85
+ { title: 'Custom', route: '/pvt/custom' },
86
+ ])
87
+
88
+ expect(result).toEqual([{ title: 'Custom', route: '/pvt/custom' }])
89
+ })
90
+ })
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ const mockFetch = vi.hoisted(() => vi.fn())
8
+
9
+ vi.mock('isomorphic-unfetch', () => ({ __esModule: true, default: mockFetch }))
10
+
11
+ import { refreshTokenRequest } from '../../../src/sdk/account/refreshToken'
12
+
13
+ describe('refreshTokenRequest (browser)', () => {
14
+ beforeEach(() => {
15
+ mockFetch.mockResolvedValue({
16
+ status: 200,
17
+ json: async () => ({ status: 'success' }),
18
+ })
19
+ })
20
+
21
+ afterEach(() => {
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ it('uses a relative URL and omits Host header in the browser', async () => {
26
+ await refreshTokenRequest()
27
+
28
+ expect(mockFetch).toHaveBeenCalledTimes(1)
29
+
30
+ const [url, init] = mockFetch.mock.calls[0]
31
+ expect(url).toBe('/api/vtexid/refreshtoken/webstore')
32
+ expect(init.headers).toEqual({ 'content-type': 'application/json' })
33
+ })
34
+ })
@@ -0,0 +1,87 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const mockFetch = vi.hoisted(() => vi.fn())
4
+
5
+ vi.mock('isomorphic-unfetch', () => ({ __esModule: true, default: mockFetch }))
6
+
7
+ vi.mock('src/sdk/localization/useLocalizationConfig', () => ({
8
+ getStoreURL: () => 'https://b2bfaststore.vtexfaststore.com',
9
+ }))
10
+
11
+ vi.mock('src/utils/utilities', () => ({
12
+ sanitizeHost: (origin: string) => origin.replace(/^https?:\/\//, ''),
13
+ }))
14
+
15
+ import {
16
+ isRefreshTokenSuccessful,
17
+ refreshTokenRequest,
18
+ } from '../../../src/sdk/account/refreshToken'
19
+
20
+ describe('refreshTokenRequest (node)', () => {
21
+ beforeEach(() => {
22
+ mockFetch.mockResolvedValue({
23
+ status: 200,
24
+ json: async () => ({ status: 'success', refreshAfter: '123' }),
25
+ })
26
+ })
27
+
28
+ afterEach(() => {
29
+ vi.clearAllMocks()
30
+ })
31
+
32
+ it('posts to the store origin with Host header on the server', async () => {
33
+ const result = await refreshTokenRequest()
34
+
35
+ expect(result).toEqual({ status: 'success', refreshAfter: '123' })
36
+ expect(mockFetch).toHaveBeenCalledTimes(1)
37
+
38
+ const [url, init] = mockFetch.mock.calls[0]
39
+ expect(url).toBe(
40
+ 'https://b2bfaststore.vtexfaststore.com/api/vtexid/refreshtoken/webstore'
41
+ )
42
+ expect(init.method).toBe('POST')
43
+ expect(init.credentials).toBe('include')
44
+ expect(init.headers).toMatchObject({
45
+ 'content-type': 'application/json',
46
+ Host: 'b2bfaststore.vtexfaststore.com',
47
+ })
48
+ expect(JSON.parse(init.body as string)).toEqual({})
49
+ })
50
+
51
+ it('retries when the response is not 200 and returns undefined after failures', async () => {
52
+ mockFetch
53
+ .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
54
+ .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
55
+ .mockResolvedValueOnce({ status: 500, json: async () => ({}) })
56
+
57
+ const result = await refreshTokenRequest()
58
+
59
+ expect(result).toBeUndefined()
60
+ expect(mockFetch).toHaveBeenCalledTimes(3)
61
+ })
62
+
63
+ it('retries after network errors and succeeds on a later attempt', async () => {
64
+ mockFetch
65
+ .mockRejectedValueOnce(new Error('network'))
66
+ .mockResolvedValueOnce({
67
+ status: 200,
68
+ json: async () => ({ status: 'success' }),
69
+ })
70
+
71
+ const result = await refreshTokenRequest()
72
+
73
+ expect(result).toEqual({ status: 'success' })
74
+ expect(mockFetch).toHaveBeenCalledTimes(2)
75
+ })
76
+ })
77
+
78
+ describe('isRefreshTokenSuccessful', () => {
79
+ it('returns true when status is success (case-insensitive)', () => {
80
+ expect(isRefreshTokenSuccessful({ status: 'Success' })).toBe(true)
81
+ })
82
+
83
+ it('returns false when status is missing or not success', () => {
84
+ expect(isRefreshTokenSuccessful(undefined)).toBe(false)
85
+ expect(isRefreshTokenSuccessful({ status: 'failed' })).toBe(false)
86
+ })
87
+ })
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { renderHook } from '@testing-library/react'
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const mockUseSession = vi.hoisted(() => vi.fn())
9
+ const mockUseQuery = vi.hoisted(() => vi.fn())
10
+
11
+ vi.mock('@generated', () => ({ gql: (query: unknown) => query }))
12
+ vi.mock('src/sdk/session', () => ({ useSession: mockUseSession }))
13
+ vi.mock('src/sdk/graphql/useQuery', () => ({ useQuery: mockUseQuery }))
14
+
15
+ import { useAvailableContracts } from '../../../src/sdk/account/useAvailableContracts'
16
+
17
+ const contracts = [
18
+ { id: 'a', corporateName: 'Corp A', isActive: true },
19
+ { id: 'b', corporateName: 'Corp B', isActive: false },
20
+ ]
21
+
22
+ describe('useAvailableContracts', () => {
23
+ afterEach(() => {
24
+ vi.clearAllMocks()
25
+ })
26
+
27
+ it('skips the query when the hook is disabled', () => {
28
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
29
+ mockUseQuery.mockReturnValue({
30
+ data: undefined,
31
+ error: null,
32
+ isValidating: false,
33
+ })
34
+
35
+ const { result } = renderHook(() => useAvailableContracts(false))
36
+
37
+ expect(result.current.contracts).toEqual([])
38
+ expect(result.current.loading).toBe(false)
39
+ expect(mockUseQuery).toHaveBeenCalledWith(
40
+ expect.anything(),
41
+ { orgUnitId: 'unit-1' },
42
+ expect.objectContaining({ doNotRun: true })
43
+ )
44
+ })
45
+
46
+ it('skips the query when orgUnitId is missing', () => {
47
+ mockUseSession.mockReturnValue({ b2b: { unitId: '' } })
48
+ mockUseQuery.mockReturnValue({
49
+ data: undefined,
50
+ error: null,
51
+ isValidating: false,
52
+ })
53
+
54
+ const { result } = renderHook(() => useAvailableContracts(true))
55
+
56
+ expect(result.current.contracts).toEqual([])
57
+ expect(mockUseQuery).toHaveBeenCalledWith(
58
+ expect.anything(),
59
+ { orgUnitId: '' },
60
+ expect.objectContaining({ doNotRun: true })
61
+ )
62
+ })
63
+
64
+ it('returns loading while fetching contracts for the current org unit', () => {
65
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
66
+ mockUseQuery.mockReturnValue({
67
+ data: undefined,
68
+ error: null,
69
+ isValidating: false,
70
+ })
71
+
72
+ const { result } = renderHook(() => useAvailableContracts(true))
73
+
74
+ expect(result.current.loading).toBe(true)
75
+ expect(result.current.contracts).toEqual([])
76
+ expect(mockUseQuery).toHaveBeenCalledWith(
77
+ expect.anything(),
78
+ { orgUnitId: 'unit-1' },
79
+ expect.objectContaining({ doNotRun: false, dedupingInterval: 0 })
80
+ )
81
+ })
82
+
83
+ it('returns contracts when the query succeeds', () => {
84
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
85
+ mockUseQuery.mockReturnValue({
86
+ data: { availableContracts: contracts },
87
+ error: null,
88
+ isValidating: false,
89
+ })
90
+
91
+ const { result } = renderHook(() => useAvailableContracts(true))
92
+
93
+ expect(result.current.contracts).toEqual(contracts)
94
+ expect(result.current.error).toBeNull()
95
+ expect(result.current.loading).toBe(false)
96
+ })
97
+
98
+ it('returns query errors without loading', () => {
99
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
100
+ mockUseQuery.mockReturnValue({
101
+ data: undefined,
102
+ error: new Error('boom'),
103
+ isValidating: false,
104
+ })
105
+
106
+ const { result } = renderHook(() => useAvailableContracts(true))
107
+
108
+ expect(result.current.error).toBeInstanceOf(Error)
109
+ expect(result.current.loading).toBe(false)
110
+ })
111
+
112
+ it('keeps loading while the query is validating', () => {
113
+ mockUseSession.mockReturnValue({ b2b: { unitId: 'unit-1' } })
114
+ mockUseQuery.mockReturnValue({
115
+ data: { availableContracts: contracts },
116
+ error: null,
117
+ isValidating: true,
118
+ })
119
+
120
+ const { result } = renderHook(() => useAvailableContracts(true))
121
+
122
+ expect(result.current.loading).toBe(true)
123
+ })
124
+ })
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { act, renderHook, waitFor } from '@testing-library/react'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const mockReload = vi.hoisted(() => vi.fn())
9
+ const mockClearPersistedSessionState = vi.hoisted(() => vi.fn())
10
+
11
+ vi.mock('src/sdk/account/clearPersistedSessionState', () => ({
12
+ clearPersistedSessionState: mockClearPersistedSessionState,
13
+ }))
14
+
15
+ import * as changeContractTokenModule from '../../../src/sdk/account/changeContractToken'
16
+ import { useSwitchContract } from '../../../src/sdk/account/useSwitchContract'
17
+
18
+ describe('useSwitchContract', () => {
19
+ beforeEach(() => {
20
+ mockClearPersistedSessionState.mockResolvedValue(undefined)
21
+ mockReload.mockReset()
22
+ Object.defineProperty(globalThis.window, 'location', {
23
+ configurable: true,
24
+ value: { ...globalThis.window.location, reload: mockReload },
25
+ })
26
+ vi.spyOn(
27
+ changeContractTokenModule,
28
+ 'changeContractToken'
29
+ ).mockResolvedValue(false)
30
+ })
31
+
32
+ afterEach(() => {
33
+ vi.clearAllMocks()
34
+ vi.restoreAllMocks()
35
+ })
36
+
37
+ it('does not clear session or reload when switch-properties returns false', async () => {
38
+ const { result } = renderHook(() => useSwitchContract())
39
+
40
+ let ok: boolean | undefined
41
+ await act(async () => {
42
+ ok = await result.current.switchContract('contract-2')
43
+ })
44
+
45
+ expect(ok).toBe(false)
46
+ expect(mockClearPersistedSessionState).not.toHaveBeenCalled()
47
+ expect(mockReload).not.toHaveBeenCalled()
48
+ expect(result.current.enabled).toBe(true)
49
+
50
+ await waitFor(() => expect(result.current.error).toBeInstanceOf(Error))
51
+ })
52
+
53
+ it('clears persisted session and reloads after a successful switch (REQ-06)', async () => {
54
+ vi.spyOn(
55
+ changeContractTokenModule,
56
+ 'changeContractToken'
57
+ ).mockResolvedValueOnce(true)
58
+
59
+ const { result } = renderHook(() => useSwitchContract())
60
+
61
+ let ok: boolean | undefined
62
+ await act(async () => {
63
+ ok = await result.current.switchContract('contract-2')
64
+ })
65
+
66
+ expect(ok).toBe(true)
67
+ expect(mockClearPersistedSessionState).toHaveBeenCalledTimes(1)
68
+ expect(mockReload).toHaveBeenCalledTimes(1)
69
+ expect(result.current.error).toBeNull()
70
+ })
71
+
72
+ it('surfaces an error when switch-properties fails', async () => {
73
+ vi.spyOn(
74
+ changeContractTokenModule,
75
+ 'changeContractToken'
76
+ ).mockRejectedValueOnce(new Error('switch failed'))
77
+
78
+ const { result } = renderHook(() => useSwitchContract())
79
+
80
+ let ok: boolean | undefined
81
+ await act(async () => {
82
+ ok = await result.current.switchContract('contract-2')
83
+ })
84
+
85
+ expect(ok).toBe(false)
86
+ expect(mockClearPersistedSessionState).not.toHaveBeenCalled()
87
+ expect(mockReload).not.toHaveBeenCalled()
88
+
89
+ await waitFor(() => expect(result.current.error).toBeInstanceOf(Error))
90
+ })
91
+
92
+ it('normalizes non-Error failures from switch-properties', async () => {
93
+ vi.spyOn(
94
+ changeContractTokenModule,
95
+ 'changeContractToken'
96
+ ).mockRejectedValueOnce('unexpected')
97
+
98
+ const { result } = renderHook(() => useSwitchContract())
99
+
100
+ let ok: boolean | undefined
101
+ await act(async () => {
102
+ ok = await result.current.switchContract('contract-2')
103
+ })
104
+
105
+ expect(ok).toBe(false)
106
+
107
+ await waitFor(() =>
108
+ expect(result.current.error?.message).toBe('Failed to switch contract')
109
+ )
110
+ })
111
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { renderHook, waitFor } from '@testing-library/react'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ vi.mock('@faststore/core/api', () => ({
9
+ gql: (s: string) => ({ __meta__: {}, query: s }),
10
+ }))
11
+
12
+ const runStartRecommendationSession = vi.hoisted(() => vi.fn())
13
+ const useLazyQueryMock = vi.hoisted(() => vi.fn())
14
+ vi.mock('src/sdk/graphql/useLazyQuery', () => ({
15
+ useLazyQuery: useLazyQueryMock,
16
+ }))
17
+
18
+ const getCookie = vi.hoisted(() => vi.fn())
19
+ vi.mock('src/utils/getCookie', () => ({ getCookie }))
20
+
21
+ const storeConfigMock = vi.hoisted(() => ({
22
+ experimental: { enableRecommendations: true },
23
+ }))
24
+ vi.mock('discovery.config', () => ({ default: storeConfigMock }))
25
+
26
+ import { useStartRecommendationSession } from 'src/sdk/analytics/hooks/useStartRecommendationSession'
27
+
28
+ beforeEach(() => {
29
+ useLazyQueryMock.mockReturnValue([runStartRecommendationSession, {}])
30
+ runStartRecommendationSession.mockResolvedValue(true)
31
+ storeConfigMock.experimental.enableRecommendations = true
32
+ })
33
+
34
+ afterEach(() => {
35
+ vi.clearAllMocks()
36
+ })
37
+
38
+ describe('useStartRecommendationSession', () => {
39
+ it('does not start a session when the session cookie already exists', async () => {
40
+ getCookie.mockReturnValue('already-started')
41
+
42
+ renderHook(() => useStartRecommendationSession())
43
+
44
+ await waitFor(() => {
45
+ expect(runStartRecommendationSession).not.toHaveBeenCalled()
46
+ })
47
+ })
48
+
49
+ it('starts a session when no session cookie is present', async () => {
50
+ getCookie.mockReturnValue(undefined)
51
+
52
+ renderHook(() => useStartRecommendationSession())
53
+
54
+ await waitFor(() => {
55
+ expect(runStartRecommendationSession).toHaveBeenCalled()
56
+ })
57
+ })
58
+
59
+ it('does not start a session when recommendations are disabled', async () => {
60
+ storeConfigMock.experimental.enableRecommendations = false
61
+ getCookie.mockReturnValue(undefined)
62
+
63
+ renderHook(() => useStartRecommendationSession())
64
+
65
+ await waitFor(() => {
66
+ expect(runStartRecommendationSession).not.toHaveBeenCalled()
67
+ })
68
+ })
69
+ })
@@ -0,0 +1,23 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const mockGetCookie = vi.hoisted(() => vi.fn())
4
+ vi.mock('src/utils/getCookie', () => ({ getCookie: mockGetCookie }))
5
+
6
+ import { getUserIdFromCookie } from 'src/sdk/analytics/utils'
7
+
8
+ afterEach(() => {
9
+ vi.clearAllMocks()
10
+ vi.unstubAllGlobals()
11
+ })
12
+
13
+ describe('getUserIdFromCookie', () => {
14
+ it('returns the cookie value when present', () => {
15
+ mockGetCookie.mockReturnValue('user-123')
16
+ expect(getUserIdFromCookie()).toBe('user-123')
17
+ })
18
+
19
+ it('falls back to an empty string when the cookie is missing', () => {
20
+ mockGetCookie.mockReturnValue(undefined)
21
+ expect(getUserIdFromCookie()).toBe('')
22
+ })
23
+ })
@@ -74,12 +74,16 @@ const QUERIES = [
74
74
  'productCount',
75
75
  'userOrder',
76
76
  'listUserOrders',
77
+ 'listUserQuotes',
77
78
  'userDetails',
78
79
  'accountProfile',
79
80
  'validateUser',
81
+ 'availableContracts',
82
+ 'isOrganizationMember',
80
83
  'pickupPoints',
81
84
  'orderEntryOperation',
82
85
  'orderFormItems',
86
+ 'recommendations',
83
87
  ]
84
88
 
85
89
  const OPTIONAL_GENERATED_QUERIES = new Set(['accountName'])
@@ -92,6 +96,7 @@ const MUTATIONS = [
92
96
  'processOrderAuthorization',
93
97
  'uploadFileToOrderEntry',
94
98
  'startOrderEntryOperation',
99
+ 'startRecommendationSession',
95
100
  ]
96
101
 
97
102
  describe('FastStore GraphQL Layer', () => {
@@ -0,0 +1,59 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { retry } from 'src/utils/retry'
4
+
5
+ afterEach(() => {
6
+ vi.clearAllMocks()
7
+ })
8
+
9
+ describe('retry', () => {
10
+ it('resolves immediately when the value is already available', async () => {
11
+ const fn = vi.fn(() => 'ready')
12
+
13
+ await expect(retry(fn)).resolves.toBe('ready')
14
+ expect(fn).toHaveBeenCalledTimes(1)
15
+ })
16
+
17
+ it('retries until a truthy value is returned', async () => {
18
+ const fn = vi
19
+ .fn<() => string>()
20
+ .mockReturnValueOnce('')
21
+ .mockReturnValueOnce('')
22
+ .mockReturnValue('later')
23
+
24
+ await expect(retry(fn, { attempts: 5, delayMs: 1 })).resolves.toBe('later')
25
+ expect(fn).toHaveBeenCalledTimes(3)
26
+ })
27
+
28
+ it('gives up after exhausting the retry budget', async () => {
29
+ const fn = vi.fn(() => '')
30
+
31
+ await expect(retry(fn, { attempts: 2, delayMs: 1 })).resolves.toBe('')
32
+ // initial call + 2 retries
33
+ expect(fn).toHaveBeenCalledTimes(3)
34
+ })
35
+
36
+ it('honours a custom `until` predicate', async () => {
37
+ const fn = vi
38
+ .fn<() => number | undefined>()
39
+ .mockReturnValueOnce(undefined)
40
+ .mockReturnValue(0)
41
+
42
+ await expect(
43
+ retry(fn, { attempts: 5, delayMs: 1, until: (v) => v !== undefined })
44
+ ).resolves.toBe(0)
45
+ expect(fn).toHaveBeenCalledTimes(2)
46
+ })
47
+
48
+ it('stops retrying once the signal is aborted', async () => {
49
+ const controller = new AbortController()
50
+ const fn = vi.fn(() => '')
51
+ controller.abort()
52
+
53
+ await expect(
54
+ retry(fn, { attempts: 5, delayMs: 1, signal: controller.signal })
55
+ ).resolves.toBe('')
56
+ // Only the initial call runs; the loop never starts because it is aborted.
57
+ expect(fn).toHaveBeenCalledTimes(1)
58
+ })
59
+ })