@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,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
+ })