@faststore/core 4.5.0-dev.0 → 4.5.0-dev.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.
- package/.turbo/turbo-generate.log +4 -4
- package/.turbo/turbo-test.log +135 -63
- package/@generated/cached-operations.json +2 -0
- package/@generated/gql.ts +18 -0
- package/@generated/graphql.ts +68 -0
- package/@generated/persisted-documents.json +3 -0
- package/@generated/schema.graphql +41 -0
- package/CHANGELOG.md +24 -0
- package/cms/faststore/components/cms_component__recommendationshelf.jsonc +86 -0
- package/cms/faststore/schema.json +89 -0
- package/discovery.config.default.js +3 -0
- package/discovery.config.js +10 -1
- package/discovery.config.local.example.js +62 -0
- package/package.json +6 -6
- package/public/icons.svg +4 -0
- package/src/Layout.tsx +6 -0
- package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/ContractSwitcher.tsx +376 -0
- package/src/components/account/Drawer/OrganizationDrawer/ContractSwitcher/index.ts +2 -0
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawer.tsx +51 -26
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerFooter.tsx +55 -0
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerHeader.tsx +45 -5
- package/src/components/account/Drawer/OrganizationDrawer/index.ts +5 -0
- package/src/components/account/Drawer/OrganizationDrawer/section.module.scss +452 -54
- package/src/components/cms/global/Components.ts +10 -0
- package/src/components/sections/ProductGallery/ProductGallery.tsx +1 -1
- package/src/components/sections/RecommendationShelf/RecommendationShelf.tsx +223 -0
- package/src/components/sections/RecommendationShelf/RecommendationShelf.types.ts +111 -0
- package/src/components/sections/RecommendationShelf/index.ts +1 -0
- package/src/components/sections/RecommendationShelf/section.module.scss +49 -0
- package/src/components/sections/RecommendationShelf/useRecommendationUserId.ts +49 -0
- package/src/components/sections/RecommendationShelf/useRecommendations.ts +55 -0
- package/src/components/sections/RecommendationShelf/vrn.ts +55 -0
- package/src/pages/[slug]/p.tsx +45 -0
- package/src/sdk/account/changeContractToken.ts +119 -0
- package/src/sdk/account/clearPersistedSessionState.ts +26 -0
- package/src/sdk/account/refreshToken.ts +21 -8
- package/src/sdk/account/useAvailableContracts.ts +49 -0
- package/src/sdk/account/useSwitchContract.ts +60 -0
- package/src/sdk/analytics/hooks/useStartRecommendationSession.ts +96 -0
- package/src/sdk/analytics/utils.ts +9 -0
- package/src/utils/retry.ts +69 -0
- package/test/components/RecommendationShelf/RecommendationShelf.browser.test.tsx +235 -0
- package/test/components/RecommendationShelf/useRecommendationUserId.test.ts +69 -0
- package/test/components/RecommendationShelf/useRecommendations.test.ts +52 -0
- package/test/components/RecommendationShelf/vrn.test.ts +56 -0
- package/test/components/account/ContractSwitcher.browser.test.tsx +236 -0
- package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
- package/test/components/account/OrganizationDrawerFooter.browser.test.tsx +54 -0
- package/test/components/account/OrganizationDrawerHeader.browser.test.tsx +55 -0
- package/test/components/account/doLogout.browser.test.ts +102 -0
- package/test/sdk/account/changeContractToken.browser.test.ts +157 -0
- package/test/sdk/account/clearPersistedSessionState.browser.test.ts +41 -0
- package/test/sdk/account/clearPersistedSessionState.test.ts +20 -0
- package/test/sdk/account/refreshToken.browser.test.ts +34 -0
- package/test/sdk/account/refreshToken.test.ts +87 -0
- package/test/sdk/account/useAvailableContracts.browser.test.ts +124 -0
- package/test/sdk/account/useSwitchContract.browser.test.ts +111 -0
- package/test/sdk/analytics/useStartRecommendationSession.test.ts +69 -0
- package/test/sdk/analytics/utils.test.ts +23 -0
- package/test/server/index.test.ts +3 -0
- package/test/utils/retry.test.ts +59 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { render, waitFor } from '@testing-library/react'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const usePDP = vi.hoisted(() => vi.fn())
|
|
7
|
+
vi.mock('@faststore/core', () => ({ usePDP }))
|
|
8
|
+
|
|
9
|
+
vi.mock('@faststore/ui', () => {
|
|
10
|
+
const passthrough = ({ children }: React.PropsWithChildren) =>
|
|
11
|
+
React.createElement(React.Fragment, null, children)
|
|
12
|
+
return { ProductShelf: passthrough, Carousel: passthrough }
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
vi.mock('src/components/product/ProductCard', () => ({
|
|
16
|
+
default: () => React.createElement('div', { 'data-testid': 'product-card' }),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('src/components/skeletons/ProductShelfSkeleton', () => ({
|
|
20
|
+
default: ({
|
|
21
|
+
children,
|
|
22
|
+
loading,
|
|
23
|
+
}: React.PropsWithChildren<{ loading?: boolean }>) =>
|
|
24
|
+
React.createElement(
|
|
25
|
+
'div',
|
|
26
|
+
{ 'data-testid': 'skeleton', 'data-loading': String(!!loading) },
|
|
27
|
+
children
|
|
28
|
+
),
|
|
29
|
+
}))
|
|
30
|
+
|
|
31
|
+
const useRecommendations = vi.hoisted(() => vi.fn())
|
|
32
|
+
vi.mock(
|
|
33
|
+
'src/components/sections/RecommendationShelf/useRecommendations',
|
|
34
|
+
() => ({
|
|
35
|
+
useRecommendations,
|
|
36
|
+
})
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const useScreenResize = vi.hoisted(() => vi.fn())
|
|
40
|
+
vi.mock('src/sdk/ui/useScreenResize', () => ({ default: useScreenResize }))
|
|
41
|
+
|
|
42
|
+
const useRecommendationUserId = vi.hoisted(() => vi.fn())
|
|
43
|
+
vi.mock(
|
|
44
|
+
'src/components/sections/RecommendationShelf/useRecommendationUserId',
|
|
45
|
+
() => ({
|
|
46
|
+
useRecommendationUserId,
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
// Keep the real store config (the cart SDK reads `storeConfig.cart` at module
|
|
51
|
+
// init) and only override the opt-in flag, which we toggle per test.
|
|
52
|
+
const recommendationsFlag = vi.hoisted(() => ({ enabled: true }))
|
|
53
|
+
vi.mock('discovery.config', async (original) => {
|
|
54
|
+
const actual = (await original()) as { default?: Record<string, unknown> }
|
|
55
|
+
const base = (actual.default ?? actual) as Record<string, unknown>
|
|
56
|
+
const experimental = (base.experimental ?? {}) as Record<string, unknown>
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
default: {
|
|
60
|
+
...base,
|
|
61
|
+
experimental: {
|
|
62
|
+
...experimental,
|
|
63
|
+
get enableRecommendations() {
|
|
64
|
+
return recommendationsFlag.enabled
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
import { RecommendationShelf } from 'src/components/sections/RecommendationShelf/RecommendationShelf'
|
|
72
|
+
|
|
73
|
+
const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
|
|
74
|
+
const CROSS_SELL_VRN = 'vrn:recommendations:acc:rec-cross-v2:campaign-1'
|
|
75
|
+
|
|
76
|
+
const recommendationData = {
|
|
77
|
+
products: [
|
|
78
|
+
{ id: 'p-1', isVariantOf: { productGroupID: 'p-1' } },
|
|
79
|
+
{ id: 'p-2', isVariantOf: { productGroupID: 'p-2' } },
|
|
80
|
+
],
|
|
81
|
+
correlationId: 'corr-1',
|
|
82
|
+
campaign: { id: 'camp-1', title: 'Recommended for you', type: 'TOP_ITEMS' },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
usePDP.mockReturnValue({ data: undefined })
|
|
87
|
+
useScreenResize.mockReturnValue({
|
|
88
|
+
isMobile: false,
|
|
89
|
+
isTablet: false,
|
|
90
|
+
isDesktop: true,
|
|
91
|
+
loading: false,
|
|
92
|
+
})
|
|
93
|
+
useRecommendationUserId.mockReturnValue('user-1')
|
|
94
|
+
recommendationsFlag.enabled = true
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
vi.clearAllMocks()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('RecommendationShelf', () => {
|
|
102
|
+
it('renders a product card per recommended product', async () => {
|
|
103
|
+
useRecommendations.mockReturnValue({
|
|
104
|
+
data: recommendationData,
|
|
105
|
+
isLoading: false,
|
|
106
|
+
error: null,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const { getAllByTestId, getByText } = render(
|
|
110
|
+
<RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(getAllByTestId('product-card')).toHaveLength(2)
|
|
115
|
+
})
|
|
116
|
+
expect(getByText('Recommended for you')).toBeTruthy()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('requests cross-sell recommendations using the current PDP product', async () => {
|
|
120
|
+
usePDP.mockReturnValue({
|
|
121
|
+
data: { product: { isVariantOf: { productGroupID: 'pg-1' } } },
|
|
122
|
+
})
|
|
123
|
+
useRecommendations.mockReturnValue({
|
|
124
|
+
data: recommendationData,
|
|
125
|
+
isLoading: false,
|
|
126
|
+
error: null,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
render(<RecommendationShelf campaignVrn={CROSS_SELL_VRN} />)
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
|
|
133
|
+
expect(lastArgs).toEqual({
|
|
134
|
+
userId: 'user-1',
|
|
135
|
+
campaignVrn: CROSS_SELL_VRN,
|
|
136
|
+
products: ['pg-1'],
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('prefers the provided title over the campaign title', async () => {
|
|
142
|
+
useRecommendations.mockReturnValue({
|
|
143
|
+
data: recommendationData,
|
|
144
|
+
isLoading: false,
|
|
145
|
+
error: null,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const { getByText } = render(
|
|
149
|
+
<RecommendationShelf campaignVrn={CAMPAIGN_VRN} title="Custom title" />
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
await waitFor(() => {
|
|
153
|
+
expect(getByText('Custom title')).toBeTruthy()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('skips the fetch and renders empty when there is no resolved userId', async () => {
|
|
158
|
+
useRecommendationUserId.mockReturnValue(null)
|
|
159
|
+
useRecommendations.mockReturnValue({
|
|
160
|
+
data: undefined,
|
|
161
|
+
isLoading: false,
|
|
162
|
+
error: null,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const { container, queryByTestId } = render(
|
|
166
|
+
<RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
|
|
171
|
+
expect(lastArgs).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
expect(queryByTestId('product-card')).toBeNull()
|
|
174
|
+
expect(container).toBeEmptyDOMElement()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('renders nothing when there is an error', () => {
|
|
178
|
+
useRecommendations.mockReturnValue({
|
|
179
|
+
data: undefined,
|
|
180
|
+
isLoading: false,
|
|
181
|
+
error: new Error('boom'),
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const { container } = render(
|
|
185
|
+
<RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
expect(container).toBeEmptyDOMElement()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('renders nothing when there are no items and loading has finished', () => {
|
|
192
|
+
useRecommendations.mockReturnValue({
|
|
193
|
+
data: { ...recommendationData, products: [] },
|
|
194
|
+
isLoading: false,
|
|
195
|
+
error: null,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const { container, queryByTestId } = render(
|
|
199
|
+
<RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
expect(queryByTestId('product-card')).toBeNull()
|
|
203
|
+
expect(container).toBeEmptyDOMElement()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('shows the skeleton while loading', () => {
|
|
207
|
+
useRecommendations.mockReturnValue({
|
|
208
|
+
data: undefined,
|
|
209
|
+
isLoading: true,
|
|
210
|
+
error: null,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const { getByTestId } = render(
|
|
214
|
+
<RecommendationShelf campaignVrn={CAMPAIGN_VRN} />
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
expect(getByTestId('skeleton').getAttribute('data-loading')).toBe('true')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('skips the fetch when recommendations are disabled', async () => {
|
|
221
|
+
recommendationsFlag.enabled = false
|
|
222
|
+
useRecommendations.mockReturnValue({
|
|
223
|
+
data: undefined,
|
|
224
|
+
isLoading: false,
|
|
225
|
+
error: null,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
render(<RecommendationShelf campaignVrn={CROSS_SELL_VRN} />)
|
|
229
|
+
|
|
230
|
+
await waitFor(() => {
|
|
231
|
+
const lastArgs = useRecommendations.mock.calls.at(-1)?.[0]
|
|
232
|
+
expect(lastArgs).toBeNull()
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
})
|
|
@@ -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
|
+
const getUserIdFromCookie = vi.hoisted(() => vi.fn())
|
|
9
|
+
vi.mock('src/sdk/analytics/utils', () => ({ getUserIdFromCookie }))
|
|
10
|
+
|
|
11
|
+
// Run the retried function once and resolve/reject with its result so the tests
|
|
12
|
+
// don't wait on the real backoff schedule.
|
|
13
|
+
vi.mock('src/utils/retry', () => ({
|
|
14
|
+
retry: (fn: () => unknown) => Promise.resolve().then(fn),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
import { useRecommendationUserId } from 'src/components/sections/RecommendationShelf/useRecommendationUserId'
|
|
18
|
+
|
|
19
|
+
const CAMPAIGN_VRN = 'vrn:recommendations:acc:rec-top-items-v2:campaign-1'
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.clearAllMocks()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('useRecommendationUserId', () => {
|
|
30
|
+
it('starts as undefined while resolving', () => {
|
|
31
|
+
getUserIdFromCookie.mockReturnValue('user-1')
|
|
32
|
+
|
|
33
|
+
const { result } = renderHook(() => useRecommendationUserId(CAMPAIGN_VRN))
|
|
34
|
+
|
|
35
|
+
expect(result.current).toBeUndefined()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('resolves the user id from the cookie', async () => {
|
|
39
|
+
getUserIdFromCookie.mockReturnValue('user-1')
|
|
40
|
+
|
|
41
|
+
const { result } = renderHook(() => useRecommendationUserId(CAMPAIGN_VRN))
|
|
42
|
+
|
|
43
|
+
await waitFor(() => {
|
|
44
|
+
expect(result.current).toBe('user-1')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('resolves to null when the cookie has no usable id', async () => {
|
|
49
|
+
getUserIdFromCookie.mockReturnValue('')
|
|
50
|
+
|
|
51
|
+
const { result } = renderHook(() => useRecommendationUserId(CAMPAIGN_VRN))
|
|
52
|
+
|
|
53
|
+
await waitFor(() => {
|
|
54
|
+
expect(result.current).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('resolves to null when the lookup throws', async () => {
|
|
59
|
+
getUserIdFromCookie.mockImplementation(() => {
|
|
60
|
+
throw new Error('boom')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const { result } = renderHook(() => useRecommendationUserId(CAMPAIGN_VRN))
|
|
64
|
+
|
|
65
|
+
await waitFor(() => {
|
|
66
|
+
expect(result.current).toBeNull()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
vi.mock('@faststore/core/api', () => ({ gql: () => ({ __meta__: {} }) }))
|
|
4
|
+
|
|
5
|
+
const useQueryMock = vi.hoisted(() => vi.fn())
|
|
6
|
+
vi.mock('src/sdk/graphql/useQuery', () => ({
|
|
7
|
+
useQuery: useQueryMock,
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
import { useRecommendations } from 'src/components/sections/RecommendationShelf/useRecommendations'
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('useRecommendations', () => {
|
|
17
|
+
it('does not run the query when args are null', () => {
|
|
18
|
+
useQueryMock.mockReturnValue({
|
|
19
|
+
data: undefined,
|
|
20
|
+
error: null,
|
|
21
|
+
isLoading: false,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const { data } = useRecommendations(null)
|
|
25
|
+
|
|
26
|
+
expect(data).toBeUndefined()
|
|
27
|
+
const options = useQueryMock.mock.calls[0][2]
|
|
28
|
+
expect(options.doNotRun).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('unwraps the recommendations payload when args are provided', () => {
|
|
32
|
+
const recommendations = {
|
|
33
|
+
products: [{ productId: 'p-1' }],
|
|
34
|
+
correlationId: 'c-1',
|
|
35
|
+
campaign: { id: 'camp-1', title: 'Top', type: 'TOP_ITEMS' },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
useQueryMock.mockReturnValue({
|
|
39
|
+
data: { recommendations },
|
|
40
|
+
error: null,
|
|
41
|
+
isLoading: false,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const args = { userId: 'u-1', campaignVrn: 'vrn:x', products: [] }
|
|
45
|
+
const { data, isLoading, error } = useRecommendations(args)
|
|
46
|
+
|
|
47
|
+
expect(data).toEqual(recommendations)
|
|
48
|
+
expect(isLoading).toBe(false)
|
|
49
|
+
expect(error).toBeNull()
|
|
50
|
+
expect(useQueryMock.mock.calls[0][2].doNotRun).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getTypeFromVrn,
|
|
5
|
+
isValidVrn,
|
|
6
|
+
} from 'src/components/sections/RecommendationShelf/vrn'
|
|
7
|
+
|
|
8
|
+
const vrn = (type: string) => `vrn:recommendations:account:${type}:campaign-1`
|
|
9
|
+
|
|
10
|
+
describe('isValidVrn', () => {
|
|
11
|
+
it('accepts every supported campaign type', () => {
|
|
12
|
+
const types = [
|
|
13
|
+
'rec-cross-v2',
|
|
14
|
+
'rec-similar-v2',
|
|
15
|
+
'rec-persona-v2',
|
|
16
|
+
'rec-last-v2',
|
|
17
|
+
'rec-top-items-v2',
|
|
18
|
+
'rec-search-v2',
|
|
19
|
+
'rec-next-v2',
|
|
20
|
+
'rec-visual-v2',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
for (const type of types) {
|
|
24
|
+
expect(isValidVrn(vrn(type))).toBe(true)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('rejects malformed or unknown vrns', () => {
|
|
29
|
+
expect(isValidVrn('not-a-vrn')).toBe(false)
|
|
30
|
+
expect(isValidVrn('vrn:recommendations:account:rec-unknown:campaign')).toBe(
|
|
31
|
+
false
|
|
32
|
+
)
|
|
33
|
+
expect(isValidVrn('vrn:recommendations:account:rec-cross-v2')).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('getTypeFromVrn', () => {
|
|
38
|
+
it('maps each vrn type to its recommendation type', () => {
|
|
39
|
+
expect(getTypeFromVrn(vrn('rec-cross-v2'))).toBe('CROSS_SELL')
|
|
40
|
+
expect(getTypeFromVrn(vrn('rec-similar-v2'))).toBe('SIMILAR_ITEMS')
|
|
41
|
+
expect(getTypeFromVrn(vrn('rec-persona-v2'))).toBe('PERSONALIZED')
|
|
42
|
+
expect(getTypeFromVrn(vrn('rec-last-v2'))).toBe('LAST_SEEN')
|
|
43
|
+
expect(getTypeFromVrn(vrn('rec-top-items-v2'))).toBe('TOP_ITEMS')
|
|
44
|
+
expect(getTypeFromVrn(vrn('rec-search-v2'))).toBe('SEARCH_BASED')
|
|
45
|
+
expect(getTypeFromVrn(vrn('rec-next-v2'))).toBe('NEXT_INTERACTION')
|
|
46
|
+
expect(getTypeFromVrn(vrn('rec-visual-v2'))).toBe('VISUAL_SIMILARITY')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns null on an unknown campaign type', () => {
|
|
50
|
+
expect(getTypeFromVrn(vrn('rec-bogus'))).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('returns null on a malformed vrn', () => {
|
|
54
|
+
expect(getTypeFromVrn('not-a-vrn')).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
const mockUseAvailableContracts = vi.hoisted(() => vi.fn())
|
|
9
|
+
const mockSwitchContract = vi.hoisted(() => vi.fn())
|
|
10
|
+
const mockUseSwitchContract = vi.hoisted(() => vi.fn())
|
|
11
|
+
|
|
12
|
+
vi.mock('src/sdk/account/useAvailableContracts', () => ({
|
|
13
|
+
useAvailableContracts: mockUseAvailableContracts,
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.mock('src/sdk/account/useSwitchContract', () => ({
|
|
17
|
+
useSwitchContract: mockUseSwitchContract,
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
vi.mock('src/sdk/session', () => ({
|
|
21
|
+
useSession: () => ({
|
|
22
|
+
b2b: { customerId: 'a', unitId: 'unit-1' },
|
|
23
|
+
}),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
import { ContractSwitcher } from '../../../src/components/account/Drawer/OrganizationDrawer/ContractSwitcher'
|
|
27
|
+
|
|
28
|
+
const contracts = [
|
|
29
|
+
{ id: 'a', corporateName: 'Stellar Global', isActive: true, isDefault: true },
|
|
30
|
+
{ id: 'b', corporateName: 'Acme Foods', isActive: false },
|
|
31
|
+
{ id: 'c', corporateName: 'Beacon Security Corp', isActive: false },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
describe('ContractSwitcher', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
mockSwitchContract.mockResolvedValue(true)
|
|
37
|
+
mockUseSwitchContract.mockReturnValue({
|
|
38
|
+
switchContract: mockSwitchContract,
|
|
39
|
+
loading: false,
|
|
40
|
+
error: null,
|
|
41
|
+
enabled: true,
|
|
42
|
+
})
|
|
43
|
+
mockUseAvailableContracts.mockReturnValue({
|
|
44
|
+
contracts,
|
|
45
|
+
loading: false,
|
|
46
|
+
error: null,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.clearAllMocks()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('shows a loading state while contracts load', () => {
|
|
55
|
+
mockUseAvailableContracts.mockReturnValue({
|
|
56
|
+
contracts: [],
|
|
57
|
+
loading: true,
|
|
58
|
+
error: null,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const { container } = render(
|
|
62
|
+
<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(
|
|
66
|
+
container.querySelector('[data-fs-contract-switcher-loading]')
|
|
67
|
+
).toBeTruthy()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('shows an error state when the list fails to load', () => {
|
|
71
|
+
mockUseAvailableContracts.mockReturnValue({
|
|
72
|
+
contracts: [],
|
|
73
|
+
loading: false,
|
|
74
|
+
error: new Error('boom'),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
78
|
+
|
|
79
|
+
expect(screen.getByRole('alert')).toBeTruthy()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('shows an empty state when there is no alternative contract', () => {
|
|
83
|
+
mockUseAvailableContracts.mockReturnValue({
|
|
84
|
+
contracts: [{ id: 'a', corporateName: 'Stellar Global', isActive: true }],
|
|
85
|
+
loading: false,
|
|
86
|
+
error: null,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
expect(
|
|
94
|
+
container.querySelector('[data-fs-contract-switcher-empty]')
|
|
95
|
+
).toBeTruthy()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('shows the active contract as the current session and lists alternatives (REQ-04, REQ-05)', () => {
|
|
99
|
+
const { container } = render(
|
|
100
|
+
<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const currentCard = container.querySelector(
|
|
104
|
+
'[data-fs-contract-switcher-current-card]'
|
|
105
|
+
)
|
|
106
|
+
expect(currentCard?.textContent).toContain('Stellar Global')
|
|
107
|
+
expect(
|
|
108
|
+
currentCard?.querySelector('[data-fs-contract-switcher-default]')
|
|
109
|
+
).toBeTruthy()
|
|
110
|
+
|
|
111
|
+
// The active contract is not offered as a selectable alternative.
|
|
112
|
+
expect(screen.getByText('Acme Foods')).toBeTruthy()
|
|
113
|
+
expect(screen.getByText('Beacon Security Corp')).toBeTruthy()
|
|
114
|
+
const options = container.querySelectorAll(
|
|
115
|
+
'[data-fs-contract-switcher-option]'
|
|
116
|
+
)
|
|
117
|
+
expect(options.length).toBe(2)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('requires selecting a contract before Confirm is enabled, then switches (REQ-03)', async () => {
|
|
121
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
122
|
+
|
|
123
|
+
const confirm = screen.getByRole('button', { name: /confirm/i })
|
|
124
|
+
expect((confirm as HTMLButtonElement).disabled).toBe(true)
|
|
125
|
+
|
|
126
|
+
fireEvent.click(screen.getByText('Acme Foods'))
|
|
127
|
+
expect((confirm as HTMLButtonElement).disabled).toBe(false)
|
|
128
|
+
|
|
129
|
+
fireEvent.click(confirm)
|
|
130
|
+
|
|
131
|
+
await waitFor(() => expect(mockSwitchContract).toHaveBeenCalledWith('b'))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('keeps Confirm disabled until a different contract is selected', () => {
|
|
135
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
136
|
+
|
|
137
|
+
const confirm = screen.getByRole('button', { name: /confirm/i })
|
|
138
|
+
expect((confirm as HTMLButtonElement).disabled).toBe(true)
|
|
139
|
+
|
|
140
|
+
fireEvent.click(screen.getByText('Acme Foods'))
|
|
141
|
+
|
|
142
|
+
expect((confirm as HTMLButtonElement).disabled).toBe(false)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('keeps Confirm disabled when switching is unavailable', () => {
|
|
146
|
+
mockUseSwitchContract.mockReturnValue({
|
|
147
|
+
switchContract: mockSwitchContract,
|
|
148
|
+
loading: false,
|
|
149
|
+
error: null,
|
|
150
|
+
enabled: false,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
154
|
+
|
|
155
|
+
fireEvent.click(screen.getByText('Acme Foods'))
|
|
156
|
+
|
|
157
|
+
const confirm = screen.getByRole('button', { name: /confirm/i })
|
|
158
|
+
expect((confirm as HTMLButtonElement).disabled).toBe(true)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('keeps Confirm disabled when the selected contract is filtered out', () => {
|
|
162
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
163
|
+
|
|
164
|
+
fireEvent.click(screen.getByText('Acme Foods'))
|
|
165
|
+
|
|
166
|
+
const confirm = screen.getByRole('button', { name: /confirm/i })
|
|
167
|
+
expect((confirm as HTMLButtonElement).disabled).toBe(false)
|
|
168
|
+
|
|
169
|
+
fireEvent.change(screen.getByLabelText('Search contracts'), {
|
|
170
|
+
target: { value: 'beacon' },
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
expect((confirm as HTMLButtonElement).disabled).toBe(true)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('filters the alternatives by search', () => {
|
|
177
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
178
|
+
|
|
179
|
+
fireEvent.change(screen.getByLabelText('Search contracts'), {
|
|
180
|
+
target: { value: 'acme' },
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(screen.getByText('Acme Foods')).toBeTruthy()
|
|
184
|
+
expect(screen.queryByText('Beacon Security Corp')).toBeNull()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('shows an empty search result message when nothing matches', () => {
|
|
188
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
189
|
+
|
|
190
|
+
fireEvent.change(screen.getByLabelText('Search contracts'), {
|
|
191
|
+
target: { value: 'zzzz-no-match' },
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
expect(screen.getByText('No contracts match your search.')).toBeTruthy()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('clears the search input', () => {
|
|
198
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
199
|
+
|
|
200
|
+
const searchInput = screen.getByLabelText(
|
|
201
|
+
'Search contracts'
|
|
202
|
+
) as HTMLInputElement
|
|
203
|
+
|
|
204
|
+
fireEvent.change(searchInput, {
|
|
205
|
+
target: { value: 'acme' },
|
|
206
|
+
})
|
|
207
|
+
fireEvent.click(screen.getByLabelText('Clear search'))
|
|
208
|
+
|
|
209
|
+
expect(searchInput.value).toBe('')
|
|
210
|
+
expect(screen.getByText('Beacon Security Corp')).toBeTruthy()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('shows a switch error when the contract change fails', () => {
|
|
214
|
+
mockUseSwitchContract.mockReturnValue({
|
|
215
|
+
switchContract: mockSwitchContract,
|
|
216
|
+
loading: false,
|
|
217
|
+
error: new Error('switch failed'),
|
|
218
|
+
enabled: true,
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
222
|
+
|
|
223
|
+
expect(screen.getByText(/couldn't switch your contract/i)).toBeTruthy()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('does not reload when the switch returns false', async () => {
|
|
227
|
+
mockSwitchContract.mockResolvedValue(false)
|
|
228
|
+
|
|
229
|
+
render(<ContractSwitcher onBack={vi.fn()} onClose={vi.fn()} />)
|
|
230
|
+
|
|
231
|
+
fireEvent.click(screen.getByText('Acme Foods'))
|
|
232
|
+
fireEvent.click(screen.getByRole('button', { name: /confirm/i }))
|
|
233
|
+
|
|
234
|
+
await waitFor(() => expect(mockSwitchContract).toHaveBeenCalledWith('b'))
|
|
235
|
+
})
|
|
236
|
+
})
|