@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.
- package/.turbo/turbo-generate.log +4 -4
- package/.turbo/turbo-test.log +137 -51
- package/@generated/cached-operations.json +4 -0
- package/@generated/gql.ts +30 -0
- package/@generated/graphql.ts +141 -0
- package/@generated/persisted-documents.json +5 -0
- package/@generated/schema.graphql +100 -0
- package/CHANGELOG.md +26 -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 +52 -23
- package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +16 -6
- 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/account/Layout/Layout.tsx +14 -5
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotes.tsx +170 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountListQuotesTable.tsx +194 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountListQuotesTable/MyAccountQuoteStatusBadge.tsx +15 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/MyAccountQuotesFilterSlider.tsx +162 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesFilterSlider/section.module.scss +49 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesSelectedTags/MyAccountQuotesSelectedTags.tsx +125 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/MyAccountQuotesStatusSelector.tsx +107 -0
- package/src/components/account/quotes/MyAccountListQuotes/MyAccountQuotesStatusSelector/styles.module.scss +144 -0
- package/src/components/account/quotes/MyAccountListQuotes/index.ts +2 -0
- package/src/components/account/quotes/MyAccountListQuotes/quoteFilters.ts +89 -0
- package/src/components/account/quotes/MyAccountListQuotes/styles.module.scss +174 -0
- 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/customizations/src/myAccount/extensions/quotes/after.tsx +5 -0
- package/src/customizations/src/myAccount/extensions/quotes/before.tsx +5 -0
- package/src/pages/[slug]/p.tsx +45 -0
- package/src/pages/pvt/account/quotes/index.tsx +264 -0
- package/src/sdk/account/changeContractToken.ts +119 -0
- package/src/sdk/account/clearPersistedSessionState.ts +26 -0
- package/src/sdk/account/getMyAccountRoutes.ts +7 -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/quoteStatus.ts +27 -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/Layout.test.tsx +62 -0
- package/test/components/account/OrganizationDrawer.browser.test.tsx +197 -0
- package/test/components/account/OrganizationDrawerBody.test.tsx +52 -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/components/quotes/MyAccountListQuotes.test.tsx +232 -0
- package/test/components/quotes/MyAccountListQuotesTable.test.tsx +240 -0
- package/test/components/quotes/MyAccountQuoteStatusBadge.test.tsx +28 -0
- package/test/components/quotes/MyAccountQuotesFilterSlider.test.tsx +152 -0
- package/test/components/quotes/MyAccountQuotesSelectedTags.test.tsx +122 -0
- package/test/components/quotes/MyAccountQuotesStatusSelector.test.tsx +85 -0
- package/test/components/quotes/quoteFilters.test.ts +185 -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/getMyAccountRoutes.test.ts +90 -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 +5 -0
- package/test/utils/retry.test.ts +59 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type QuoteStatusKey =
|
|
2
|
+
| 'Draft'
|
|
3
|
+
| 'Requested'
|
|
4
|
+
| 'InReview'
|
|
5
|
+
| 'Reviewed'
|
|
6
|
+
| 'Approved'
|
|
7
|
+
| 'Declined'
|
|
8
|
+
| 'Expired'
|
|
9
|
+
| 'ConvertedToCart'
|
|
10
|
+
| 'ConvertedToOrder'
|
|
11
|
+
|
|
12
|
+
export type QuoteStatusMapValue = {
|
|
13
|
+
variant: 'neutral' | 'warning' | 'info' | 'success' | 'danger'
|
|
14
|
+
label: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const quoteStatusMap: Record<QuoteStatusKey, QuoteStatusMapValue> = {
|
|
18
|
+
Draft: { variant: 'neutral', label: 'Draft' },
|
|
19
|
+
Requested: { variant: 'warning', label: 'Requested' },
|
|
20
|
+
InReview: { variant: 'warning', label: 'In Review' },
|
|
21
|
+
Reviewed: { variant: 'info', label: 'Revised' },
|
|
22
|
+
Approved: { variant: 'success', label: 'Approved' },
|
|
23
|
+
Declined: { variant: 'danger', label: 'Declined' },
|
|
24
|
+
Expired: { variant: 'neutral', label: 'Expired' },
|
|
25
|
+
ConvertedToCart: { variant: 'success', label: 'Converted' },
|
|
26
|
+
ConvertedToOrder: { variant: 'success', label: 'Converted to Order' },
|
|
27
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface RetryOptions<T> {
|
|
2
|
+
/**
|
|
3
|
+
* Maximum number of retries after the initial attempt.
|
|
4
|
+
* @default 5
|
|
5
|
+
*/
|
|
6
|
+
attempts?: number
|
|
7
|
+
/**
|
|
8
|
+
* Delay between attempts, in milliseconds.
|
|
9
|
+
* @default 500
|
|
10
|
+
*/
|
|
11
|
+
delayMs?: number
|
|
12
|
+
/**
|
|
13
|
+
* Doubles the delay after each attempt, capped at `maxDelayMs`.
|
|
14
|
+
* @default false
|
|
15
|
+
*/
|
|
16
|
+
backoff?: boolean
|
|
17
|
+
/**
|
|
18
|
+
* Upper bound for the delay when `backoff` is enabled.
|
|
19
|
+
* @default 3000
|
|
20
|
+
*/
|
|
21
|
+
maxDelayMs?: number
|
|
22
|
+
/**
|
|
23
|
+
* Stops retrying once this predicate returns `true`. Defaults to "the value
|
|
24
|
+
* is truthy".
|
|
25
|
+
*/
|
|
26
|
+
until?: (value: T) => boolean
|
|
27
|
+
/**
|
|
28
|
+
* Aborts the retry loop early (e.g. on component unmount).
|
|
29
|
+
*/
|
|
30
|
+
signal?: AbortSignal
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Repeatedly calls `fn` until `until` is satisfied or the retry budget is
|
|
34
|
+
// exhausted. Useful when a value is produced asynchronously by an external
|
|
35
|
+
// script (e.g. a cookie set by a pixel, or an endpoint that is not ready on
|
|
36
|
+
// the first call). Returns the last value produced, even when the budget runs
|
|
37
|
+
// out, so callers can decide how to handle the give-up case.
|
|
38
|
+
export async function retry<T>(
|
|
39
|
+
fn: () => T | Promise<T>,
|
|
40
|
+
{
|
|
41
|
+
attempts = 5,
|
|
42
|
+
delayMs = 500,
|
|
43
|
+
backoff = false,
|
|
44
|
+
maxDelayMs = 3000,
|
|
45
|
+
until = Boolean,
|
|
46
|
+
signal,
|
|
47
|
+
}: RetryOptions<T> = {}
|
|
48
|
+
): Promise<T> {
|
|
49
|
+
let value = await fn()
|
|
50
|
+
let delay = delayMs
|
|
51
|
+
let remaining = attempts
|
|
52
|
+
|
|
53
|
+
while (!until(value) && remaining > 0 && !signal?.aborted) {
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
55
|
+
|
|
56
|
+
if (signal?.aborted) {
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
value = await fn()
|
|
61
|
+
remaining -= 1
|
|
62
|
+
|
|
63
|
+
if (backoff) {
|
|
64
|
+
delay = Math.min(delay * 2, maxDelayMs)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
@@ -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
|
+
})
|